﻿
using System;
using System.Collections.Generic;
using System.Text;
using Stauffware.Common.Core;

namespace Stauffware.Common.Time
{
	public static partial class DateParseUtils
	{
		//-----------------------------------------------------------------------------
		/// <summary>
		/// Converts a string to a date.
		/// </summary>
		/// <exception cref="DateTimeParseException">thrown on a syntax error</exception>
		/// <remarks>
		/// The string is assumed to contain only a date, with optional
		/// leading and trailing whitespace.
		/// This method recognizes a wide variety of date formats; it accepts
		/// pretty much anything that isn't ambiguous.
		/// Years must be given as 4 digits.
		/// If the day is greater than 12, the month may be given by number.
		/// Months may be specified by any unambiguous abbreviation.
		/// Components may be delimited by spaces, hyphens, or forward slashes,
		/// but the same delimiter must be used consistently.
		/// Month and day may appear in any order; year may appear before or after
		/// the month and day.
		/// </remarks>

		public static DateTime ParseDate (string input)
		{
			string originalInput = input;	// save it for error reporting

			// tokenize the input into numbers, names, and separators
			List<Token> tokens = LexDateTokens(ref input);

			// Match the input tokens against all the date token patterns, and
			// get two lists of date patterns that the input matches lexically.

			List<PatternMatch> goodMatches = new List<PatternMatch>();	// lexical matches that yielded a valid date & time
			List<PatternMatch> badMatches = new List<PatternMatch>();	// lexical matches that contained invalid data
			PatternMatch.GetLexicalMatches(tokens, goodMatches, badMatches);

			// if we found at least one good match
			if (goodMatches.Count > 0)
			{
				// if they all got the same date result (or there's only one match), then we're good
				if (DatesAllMatch(goodMatches))
					return goodMatches[0].DateTime.Value;

				// remove patterns that are disallowed by the current parsing rules

				for (int i = goodMatches.Count - 1; i >= 0; i--)
				{
					if (DefaultDayMonthOrder == DayMonthOrder.DayMonthWhenAmbiguous)
						if (goodMatches[i].DatePattern.DayIndex < goodMatches[i].DatePattern.MonthIndex)
							continue;

					if (DefaultDayMonthOrder == DayMonthOrder.MonthDayWhenAmbiguous)
						if (goodMatches[i].DatePattern.MonthIndex < goodMatches[i].DatePattern.DayIndex)
							continue;

					goodMatches.RemoveAt(i);
				}

				// if the remaining matches (if any) all got the same result (or there's only one match), then we're good
				if (goodMatches.Count > 0)
					if (DatesAllMatch(goodMatches))
						return goodMatches[0].DateTime.Value;
			}

			// if we got here, we have no good matches

			if (badMatches.Count == 0)
				throw new DateTimeParseException(input, "invalid date");
			else
				throw badMatches[0].Error;
		}

		//-----------------------------------------------------------------------------
		/// Returns whether all the dates are the same in the given list of matches.

		private static bool DatesAllMatch (List<PatternMatch> matches)
		{
			Require.Condition(matches.Count > 0);
			Require.Condition(matches[0].DateTime.HasValue);

			DateTime first = matches[0].DateTime.Value;

			for (int i = 1; i < matches.Count; i++)
				if (first != matches[i].DateTime)
					return false;

			return true;
		}

		//-----------------------------------------------------------------------------

		// valid date separator characters (TO-DO: should be in a resource)
		private static readonly char[] DateSeparators = { '/', '-', '.', Chars.EnDash  };

		// date characters that require special handling
		private static readonly char[] SpecialDateChars = { ',' };

		//=============================================================================
		/// <summary>
		/// A DatePattern object represents one possible pattern of date tokens.
		/// </summary>
		/// <remarks>
		/// A DatePattern object is essentially a string containing characters that
		/// represent date tokens:
		/// 
		///	M = month name		W = weekday name	Y = 4-digit year		! = separator
		///	m = month number	d = day number		[y = 2-digit year]		, = special char
		/// 
		/// NOTE: support for 2-digit years is incomplete, and currently not supported.
		/// </remarks>

		private class DatePattern
		{
			// The list of all valid date pattern strings.  
			// (TO-DO: should probably be in a resource.)
			private static readonly string[] _patterns =
			{
			//	YMD variants		YDM variants		DMY variants		MDY variants
				"YMd",				"YdM",				"dMY",				"MdY",
				"YMdW",				"YWdM",				"WdMY",				"WMdY",
				"Y!M!d",			"Y!d!M",			"d!M!Y",			"Md,Y",
				"Ymd",				"Ydm",				"Wd!M!Y",			"WMd,Y",
				"YmdW",				"YWdm",				"dmY",				"M!d!Y",
				"Y!m!d",			"Y!d!m",			"WdmY",				"WM!d!Y",
				"Y!m!dW",								"d!m!Y",			"m!d!Y",
														"Wd!m!Y",			"Wm!d!Y",
			};

			/// All the patterns, in no particular order.
			public static readonly IReadOnlyList<DatePattern> All;
										
			// class constructor
			static DatePattern ()
			{
				// Create the DatePattern objects from the pattern strings.

				List<DatePattern> pats = new List<DatePattern>();
				All = new ReadOnlyList<DatePattern>(pats);

				for (int i = 0; i < _patterns.Length; i++)
				{
#if DEBUG
					Assert.Condition(Array.LastIndexOf(_patterns, _patterns[i]) == i, 
											"duplicate pattern: \"" + _patterns[i]);
#endif
					pats.Add(new DatePattern(_patterns[i]));
				}
			}

			//---------------------------------------------------------

			public readonly string Pattern;					// The pattern string.

			// These fields provide the index in the pattern of the relevant token.
			// A value of -1 means the token is not present.
			public readonly sbyte YearIndex;		
			public readonly sbyte MonthIndex;		
			public readonly sbyte DayIndex;			
			public readonly sbyte DayOfWeekIndex;	

			/// The length of the pattern string, and the number of tokens in the pattern.
			public int Length { get { return this.Pattern.Length; } }
			public bool HasMonthName { get { return this.Pattern[this.MonthIndex] == 'M'; } }

			private static readonly string ValidChars = "dMmWYy!,";

			//---------------------------------------------------------
			// constructor

			private DatePattern (string pat)
			{
#if DEBUG
				Require.NotNull(pat);
				foreach (char c in pat)
					Require.Condition(ValidChars.Contains(c), "bogus char in pattern - \"" + pat + "\"");
#endif
				this.Pattern = pat;
				this.YearIndex = (sbyte) pat.IndexOfAny("Yy".ToCharArray());
				this.MonthIndex = (sbyte) pat.IndexOfAny("Mm".ToCharArray());
				this.DayIndex = (sbyte) pat.IndexOf('d');
				this.DayOfWeekIndex = (sbyte) pat.IndexOf('W');
#if DEBUG
				Require.Condition(this.YearIndex >= 0, "bad pattern: no year - \"" + pat + "\"");
				Require.Condition(this.MonthIndex >= 0, "bad pattern: no month - \"" + pat + "\"");
				Require.Condition(this.DayIndex >= 0, "bad pattern: no day - \"" + pat + "\"");
				// (day of week is optional, but there better not be more than 1)
				Require.Condition(this.YearIndex == pat.LastIndexOfAny("Yy".ToCharArray()),
										"bad pattern: multiple years - \"" + pat + "\"");
				Require.Condition(this.MonthIndex == pat.LastIndexOfAny("Mm".ToCharArray()),
										"bad pattern: multiple months - \"" + pat + "\"");
				Require.Condition(this.DayIndex == pat.LastIndexOf('d'),
										"bad pattern: multiple days - \"" + pat + "\"");
				Require.Condition(this.DayOfWeekIndex == pat.LastIndexOf('W'),
										"bad pattern: multiple weekdays - \"" + pat + "\"");
#endif
			}

			// for debugging
			public override string ToString () { return "\"" + this.Pattern + "\""; }
		}

		//=============================================================================

		/// <summary>
		/// Types of tokens (lexemes) that can appear in a date.
		/// </summary>
		private enum TokenType 
		{
			Number,		// sequence of digits (could be a year, month or day)
			Name,		// sequence of letters (could be a month name or a weekday name)
			Separator,	// a date separator character
			Special		// a character that requires special handling (e.g. ',')
		}

		//-----------------------------------------------------------------------------
		/// <summary>
		/// Represents a single lexical token in a date.
		/// </summary>

		private class Token
		{
			public readonly TokenType Type;		// type of token
			public readonly string Text;		// the original text of the token from the input string

			// constructor
			public Token (string text, TokenType type)
			{
				Require.Condition(!string.IsNullOrWhiteSpace(text));
				this.Type = type;
				this.Text = text;
			}

			/// returns whether this token matches the given date pattern element
			public virtual bool MatchesPattern (char p)
			{
				if (p == '!')
					return this.Type == TokenType.Separator;
				else return this.Type == TokenType.Special;
			}

			/// Returns the year value of the token.
			public virtual ushort Year { get { Assert.NotReached(); return 0; } }
			
			/// Returns the month value of the token (1-12).
			public virtual ushort Month { get { Assert.NotReached(); return 0; } }
	
			/// Returns the day value of the token (1-31).
			public virtual ushort Day { get { Assert.NotReached(); return 0; } }

			/// Returns the day-of-week value of the token.
			public virtual DayOfWeek DayOfWeek { get { Assert.NotReached(); return 0; } }

			// for debugging
			public override string ToString () 
			{ return this.Type.ToString() + " \"" + this.Text + "\""; }
		}

		//-----------------------------------------------------------------------------
		/// Represents a number that appears in a date.

		private class Number : Token
		{
			public readonly ushort Value;
			public readonly bool IsValidYear;	// could it be a valid year?
			public readonly bool IsValidMonth;	// could it be a valid month?
			public bool IsValidDay;				// could it be a valid day in some month?

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// Constructor.  Ensures that the number isn't completely bogus.

			public Number (string digits)
				: base(digits, TokenType.Number)
			{
				if (!ushort.TryParse(digits, out this.Value))
					throw new DateTimeParseException(null, digits, "number is not a valid date number");
				this.Value = ushort.Parse(digits);
				this.IsValidYear = (this.Value > DateTime.MinValue.Year) && (this.Value <= DateTime.MaxValue.Year);
				this.IsValidMonth = (this.Value > 0) && (this.Value <= 12);
				this.IsValidDay = (this.Value > 0) && (this.Value <= 31);
				if (!(this.IsValidDay || this.IsValidMonth || this.IsValidYear))
					throw new DateTimeParseException(null, digits, "number is not a valid date number");
			}

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// returns whether this token matches the given date pattern element

			public override bool MatchesPattern (char p)
			{
				switch (p)
				{
					case 'd': return this.IsValidDay;
					case 'm': return this.IsValidMonth;
					case 'Y': return this.IsValidYear && this.Text.Length == 4;
					case 'y': return this.IsValidYear && this.Text.Length == 2;
					default: return false;
				}
			}

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

			/// Returns the year value of the token.
			public override ushort Year { get 
			{ 
				Assert.Condition(this.IsValidYear);
				return this.Value;
			} }

			/// Returns the month value of the token (1-12).
			public override ushort Month { get 
			{ 
				Assert.Condition(this.IsValidMonth);
				return this.Value;
			} }
	
			public override ushort Day { get 
			{ 
				Assert.Condition(this.IsValidDay);
				return this.Value;
			} }			
		}

		//-----------------------------------------------------------------------------

		/// Represents a name (month or day of week) that appears in a date.
		private class Name : Token
		{
			private byte _month;	// month number, or 0
			private byte _weekDay;	// day number, or 0

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// Constructor.  Ensures that the given input unambiguously matches
			// exactly one month name or day name.

			public Name (string input)
				: base(input, TokenType.Name)
			{
				input = input.ToLower();
				int dayMatch = GetOneMatch(input, DayNamesLC);
				int monthMatch = GetOneMatch(input, MonthNamesLC);

				if (dayMatch >= 0 && monthMatch == -2)			// matches one day name, no month names
					this._weekDay = (byte) (dayMatch + 1);
				else if (monthMatch >= 0 && dayMatch == -2)		// matches one month name, no day names
					this._month = (byte) (monthMatch + 1);
				else if (dayMatch == -2 && monthMatch == -2)		// matches no names
					throw new DateTimeParseException(input, "invalid day or month name");
				else if (dayMatch == -1 && monthMatch == -2)	// matches multiple day names, no month names
					throw new DateTimeParseException(input, "ambiguous day name");
				else if (monthMatch == -1 && dayMatch == -2)	// matches multiple month names, no day names
					throw new DateTimeParseException(input, "ambiguous month name");
				else if (dayMatch >= -1 && monthMatch >= -1)	// matches multiple month and day names
					throw new DateTimeParseException(input, "ambiguous day or month name");
				else
					Assert.NotReached();
			}

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// Returns the index of the name with an initial prefix matching 'input'.
			// Returns -2 if no match was found; returns -1 if multiple matches were found.

			private static int GetOneMatch (string input, IList<string> names)
			{
				int match = -2;		// no match
				for (int i = 0; i < names.Count; i++)
				{
					if (!names[i].StartsWith(input))
						continue;
					else if (match >= 0)
						return -1;		// multiple matches
					else
						match = i;
				}

				return match;
			}

			/// returns whether this token matches the given date pattern element
			public override bool MatchesPattern (char p)
			{
				if (p == 'W') return this._weekDay > 0;
				if (p == 'M') return this._month > 0;
				return false;
			}

			/// Returns the day-of-week value of the token.
			public override DayOfWeek DayOfWeek { get 
			{ 
				Assert.Condition(this._weekDay > 0);
				return (DayOfWeek) (this._weekDay - 1);
			} }

			/// Returns the month value of the token (1-12).
			public override ushort Month { get 
			{ 
				Assert.Condition(this._month > 0);
				return this._month;
			} }
		}

		//=============================================================================
		/// Strips all the date tokens off the input string and returns them.

		private static List<Token> LexDateTokens (ref string input)
		{
			List<Token> tokens = new List<Token>();
			Token tkn;
			while (null != (tkn = LexNextDateToken(ref input)))
				tokens.Add(tkn);

			return tokens;
		}

		//-----------------------------------------------------------------------------
		/// Strips the next date token off the input string and returns it.
		/// Returns null if there isn't one

		private static Token LexNextDateToken (ref string input)
		{
			Require.NotNull(input);
			input = input.TrimStart();
			
			if (input == "")
				return null;
			else if (char.IsDigit(input[0]))
				return new Number(TakeWhile(ref input, c => char.IsDigit(c)));
			else if (char.IsLetter(input[0]))
				return new Name(TakeWhile(ref input, c => char.IsLetter(c)));
			
			TokenType tt;
			
			if (DateSeparators.Contains(input[0]))
				tt = TokenType.Separator;
			else if (SpecialDateChars.Contains(input[0]))
				tt = TokenType.Special;
			else throw new DateTimeParseException(null, input.Substring(0,1), "invalid character in date");

			Token tkn = new Token(input.Substring(0, 1), tt);
			input = input.Substring(1);
			return tkn;
		}

		//-----------------------------------------------------------------------------
		/// Removes leading characters from input while the predicate is true,
		/// and returns the removed characters.

		private static string TakeWhile (ref string input, Predicate<char> test)
		{
			if (input == null)
				return null;

			int i;
			for (i = 0; i < input.Length; i++)
				if (!test(input[i]))
					break;

			string output = input.Substring(0, i);
			input = input.Substring(i);
			return output;
		}

		//=============================================================================
		/// <summary>
		/// A PatternMatch object represents a successful lexical match between an 
		/// input string and a DatePattern.  
		/// The match may or may not yield a good DateTime, depending on the values
		/// of the input tokens.
		/// (If the input doesn't lexically match a given DatePattern, then no
		/// PatterMatch object is created.)
		/// </summary>

		private class PatternMatch
		{
			public readonly DatePattern DatePattern;	// the pattern that matched
			// NOTE: exactly one of Error and DateTime will be null.
			public readonly DateTimeParseException Error;	// for bad data, an error (exception)
			public readonly DateTime? DateTime;			// for good data, the DateTime

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			/// <summary>
			/// Returns all the DatePatterns that lexically match a list of input tokens.
			/// </summary>
			/// <param name="tokens">the list of input tokens to match</param>
			/// <param name="goodMatches">the matches that yielded a good DateTime</param>
			/// <param name="badMatches">the lexical matches that contained bad data</param>

			public static void GetLexicalMatches (List<Token> tokens,
								List<PatternMatch> goodMatches, List<PatternMatch> badMatches)
			{
				List<PatternMatch> matches = new List<PatternMatch>();

				foreach (DatePattern pat in DatePattern.All)
				{
					PatternMatch pm = PatternMatch.Try(tokens, pat);
					if (pm == null)
						continue;
					else if (pm.Error != null)
						badMatches.Add(pm);
					else
						goodMatches.Add(pm);
				}
			}

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// Tries to match a set of input tokens against a DatePattern.

			private static PatternMatch Try (List<Token> tokens, DatePattern pattern)
			{
				// see if the rules allow the pattern at all

				if (DefaultDayMonthOrder == DayMonthOrder.DayMonthStrict)
					if ((pattern.DayIndex > pattern.MonthIndex) && !pattern.HasMonthName)
						return null;

				if ((DefaultDayMonthOrder == DayMonthOrder.MonthDayStrict) && !pattern.HasMonthName)
					if (pattern.MonthIndex > pattern.DayIndex)
						return null;

				// attempt a lexical match

				if (tokens.Count != pattern.Length)
					return null;

				for (int i = 0; i < pattern.Length; i++)
					if (!tokens[i].MatchesPattern(pattern.Pattern[i]))
						return null;

				int year = tokens[pattern.YearIndex].Year;
				int month = tokens[pattern.MonthIndex].Month;
				int day = tokens[pattern.DayIndex].Day;

				DateTime dt;

				try	{ dt = new DateTime(year, month, day); }
				catch (ArgumentException x)
				{ 
					return new PatternMatch(pattern, null, new DateTimeParseException(x.Message)); 
				}

				// see if there's a weekday

				if (pattern.DayOfWeekIndex >= 0)
					if (dt.DayOfWeek != tokens[pattern.DayOfWeekIndex].DayOfWeek)
						return new PatternMatch(pattern, null, new DateTimeParseException(
													"day of week doesn't match date"));

				return new PatternMatch(pattern, dt, null);
			}

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// constructor 

			private PatternMatch (DatePattern pattern, DateTime? dt, DateTimeParseException err)
			{
				Require.Condition((dt == null) != (err == null), "exactly one of dt and err must be null");
				this.DatePattern = pattern;
				this.DateTime = dt;
				this.Error = err;
			}

			// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
			// for debugging

			public override string ToString ()
			{
				if (this.Error != null)
					return this.DatePattern.ToString() + " \"" + this.Error.Message + "\"";
				if (this.DateTime.HasValue)
					return this.DatePattern.ToString() + " " + this.DateTime.Value.ToString("ddd d MMM yyyy");
				return this.DatePattern.ToString() + " BOGUS!";
			}
		}

		//=============================================================================
	}
}
